Skip to content

Hardening: close the residues recorded through layer 8 - #184

Merged
EiffL merged 13 commits into
clean_rebuildfrom
hardening
Aug 21, 2026
Merged

Hardening: close the residues recorded through layer 8#184
EiffL merged 13 commits into
clean_rebuildfrom
hardening

Conversation

@EiffL

@EiffL EiffL commented Aug 20, 2026

Copy link
Copy Markdown
Member

The rebuild is functionally complete through layer 8; this PR closes the
recorded residues that do not need Perlmutter access, one commit per
decision. Each choice below was settled explicitly before implementation.

What lands

  • Ambient UV_* scrub (closes Scrub the ambient UV_* namespace before invoking uv #179): project.child_env drops every
    UV_* variable outside a closed plumbing allowlist (cache dir,
    timeouts, TLS, air-gap mode, index credentials, uv's recursion guard),
    so an exported UV_NO_BINARY or UV_PYTHON can no longer steer a sync
    while env_version reports nothing moved. The run verbs warn with the
    names of any non-empty variable dropped, from the same predicate.
  • Machine-level uv.toml advisory (env_version cannot see user- or system-level uv.toml, and uv has no "pyproject.toml only" mode #176, option 2): the scan checks
    uv's two documented user/system config paths for audited
    install-settings keys — key presence, not file presence, because list
    settings concatenate across levels — and reports a hit beside
    sdist_built. Never hashed.
  • Network uncontrolled on every mechanism: the OCI wrap drops
    --network none, its one restriction, so all three mechanisms attest
    network: allowed symmetrically and no consumer can read a promise
    into "containerized". denied stays in the type for a mechanism that
    genuinely emits a denial flag. Retires the Perlmutter spike item about
    --network none hanging on compute nodes.
  • Recipe write scope narrowed to its own output directory (reverses
    the recorded "not narrowed" decision): closes the cross-write residue —
    a concurrent task landing bytes in a sibling's directory before the
    sibling hashed produced a manifest that was self-consistent and wrong,
    undetectable by any checksum, so prevention is the only fix. A probe
    keeps results/ whole (no output id) — the one probe/recipe asymmetry,
    and it excludes exactly the accident being prevented. The new
    enforcement test is mutation-checked through Unavailable().
  • uv_version manifest attestation: the engine-closure decision's
    concrete loss, recorded — probed once per run by the driver and handed
    down (the HEAD discipline), beside lc_version. Attestation, never
    identity.
  • Per-file sha256 in the crate, from annex keys: every output file is
    a File under its dataset's hasPart with sha256/contentSize
    parsed from its SHA256E key — verifiable by sha256sum after a
    git archive deposit, available bytes-free because keys are repository
    state (--include=* is load-bearing). Non-SHA-256 keys yield size and
    no digest; git-carried files hash their bytes. Also fixes a latent
    honesty bug: out-of-tree inputs published lc's framed digest under
    the workflow-run sha256 term; they now publish none. Validator floor
    unchanged at five.
  • crate: line in lc status: the rerun-lag residue made visible by
    comparing the document's own datePublished against the newest
    manifest finished_at the walk already read — no git, no rocrate
    import on status's path.
  • End-of-run edit warning: an edit made while the graph ran (the
    git_dirty window) is now named in the report; still no manifest
    field, by decision.
  • [project.scripts] absence pinned: the recorded review item — a
    metadata test asserts the worker and the shim never become console
    scripts.

Re-examined and deliberately left (recorded in CLAUDE.md)

  • The forged-run-record-subject residue: the threat model excludes
    adversaries, and a copied subject is already deliberate.
  • The validator RECOMMENDED floor stays pinned at five — no
    publication-metadata config surface.

Housekeeping

Closed stale pre-rebuild issues #166, #128, #153 with pointers;
commented triage on #152 and the advisory landing on #176.

Verification

Full suite: 582 passed (sandbox enforcement real on Linux; crate smoke
with the official rocrate-validator, REQUIRED clean and the floor
unchanged). ruff and mypy clean. Cross-write denial mutation-checked
through Unavailable() (rc 0, bytes replaced, unsandboxed).

Closes #179.

🤖 Generated with Claude Code

https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa

EiffL and others added 13 commits August 20, 2026 19:28
… uv.toml

The ambient half (#179): child_env() drops every UV_* variable outside a
closed plumbing allowlist — cache location, timeouts, TLS, air-gap mode,
index credentials, uv's own recursion guard — so an exported UV_NO_BINARY
or UV_PYTHON can no longer steer what a sync installs while env_version
reports nothing moved. The run verbs (materialize, lc run) name any
non-empty variable the scrub dropped, from the same predicate, so the
report cannot disagree with the scrub.

The config-file half (#176, the advisory option): uv merges user- and
system-level uv.toml underneath the project's settings, and list settings
concatenate across levels — so the scan now checks the two documented
paths per platform for audited install-settings keys and reports a hit
beside sdist_built. Reported, never hashed: machine state in env_version
would make one commit answer differently on two hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
…the OCI wrap

The containerized backend was the one mechanism restricting the network,
which made "containerized" carry a promise the direct mechanisms never
made — and the direction of the recorded decision is symmetry: nothing
pretends to a control it does not apply. The wrap now emits no --network
flag, the attestation says `allowed` like landlock and seatbelt, and the
`denied` literal stays reserved for a mechanism that genuinely emits a
denial flag. This also retires the Perlmutter spike item about
`--network none` hanging on compute nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
Closes the cross-write residue: a concurrent task landing bytes in a
sibling's output directory before the sibling hashed produced a manifest
that was self-consistent and wrong — undetectable by any checksum, so
prevention is the only fix. exec_policy gains one keyword (output_dir),
handed down from the worker's task; a probe has no output id and keeps
results/ whole, which is now the one probe/recipe asymmetry: the
probe→recipe promise excludes exactly the commands that write outside
their own output directory. All three mechanisms express the narrower
shape natively (the same nested-writable-directory form, one level
deeper), and the OCI mount table inherits it from the write set with no
backend change. Integrity-answering stays data_version's job alone.

The enforcement test is mutation-checked: the same cross-write through
Unavailable() succeeds (verified rc 0, bytes replaced).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
The engine-closure decision's concrete loss was that nothing recorded
which uv converged the environment — the one tool between the lock and
the installed artifacts. The driver probes `uv --version` once per run
and hands it down (the HEAD discipline; the rerun entry point probes its
own), and the worker records it beside lc_version. Attestation, never
identity: defaulted empty, outside both hashes, never read by classify,
and an unparseable probe records "" rather than failing a run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
… as sha256

Every file in an output directory now appears in the crate as a File
under its dataset's hasPart, carrying sha256 and contentSize parsed from
its SHA256E annex key — the raw digest `sha256sum` can verify after a
`git archive` deposit, available with none of the bytes fetched because
keys are repository state (dataset.annex_keys; `--include=*` is
load-bearing — bare `find` lists only present files). A non-SHA-256
backend key yields size and no digest, never a wrong one; git-carried
files (the lock, universes, manifests) hash their own bytes. The key map
is injected into render like the writer, so the builder stays git-free
and the render pure.

This also fixes a latent honesty bug: an out-of-tree declared input's
recorded input_versions digest is lc's *framed* hash, and publishing it
under the workflow-run `sha256` term claimed a checksum nothing could
verify. Externals now publish no digest — the manifests keep the full
story, the layer's stated weaker promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
The rerun-lag residue made visible. The render pins datePublished to the
newest manifest finished_at, so status can read lag off the document
itself — a content comparison against the manifests the walk already
read, with no git call and no rocrate import (the crate stays the one
materialize-only dependency on status's path; license_of and
CRATE_FILENAME move to project.py for the same reason). Four states:
not maintained / will be created / up to date / behind, plus an honest
"unreadable" for a corrupted file — and the line lands in --json through
as_dict like the rest of the header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
The mid-run edit hole, surfaced: the dirty check runs at start of run
while manifests are written per-output later, so an edit in between left
manifests whose git_sha no longer described the code that ran, silently.
The run now ends with one dataset.status call — the tree started clean
and save/restore keeps results/ clean, so any dirt appeared mid-run —
and warns with the edited paths. Still a warning, never a manifest
field: the spec's git_dirty stays unwritten by decision.

Also closes the recorded review item: a metadata test now pins that no
[project.scripts] entry ever targets lightcone.engine or the sandbox
shim — every entry point is the CLI or a mirrored git-annex executable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
The uv identity holes (scrub landed, machine-config advisory), the
crate's raw-digest rule and the injected key map, the status crate line,
and the launcher-scrub note resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
The sweep: dataset.save now commits with the same pathspec it stages —
a partial commit, built from HEAD plus its own paths alone — so work the
user staged while a graph ran stays staged and warned-about, never swept
into an output or crate commit. The nothing-to-commit probe is scoped
the same way, and the annex per-add config rides on the commit too,
since a partial commit takes the paths through the clean filter again.
The stronger move — a frozen execution worktree — is recorded as
deferred to the venue era, not rejected.

The crate's byte fallback re-checks the annex pointer shape before
hashing: annex_keys answers empty for the whole repository when
git-annex cannot answer at all, and a pointer file reads perfectly well,
so one failed `git annex find` would otherwise publish a well-formed
digest of the pointer text for every output file. The key parse splits
from the last tab (git-annex emits ${file} unescaped; keys never contain
tabs), and _file stops hashing the license file twice.

The scrub allowlist keeps four more plumbing variables —
UV_PYTHON_INSTALL_DIR (the interpreter store has no project-level
spelling, so scrubbing it shipped a remedy that does not exist),
UV_LINK_MODE, UV_PYTHON_INSTALL_MIRROR, UV_KEYRING_PROVIDER — and the
warning is composed once (project.uv_scrub_warning) and surfaced by
every uv-acting verb: init and build now say it too, instead of leaving
a corporate-mirror user with uv's raw resolution error. The machine
config probe covers XDG_CONFIG_DIRS, keeping its "complete" claim true.

Honesty edges: the crate status line says "up to date with the outputs"
/ "behind the outputs" — the exact scope of its datePublished proxy,
correct in both directions (a dropped output regresses the newest stamp
as a rerun advances it); the write-denial remedy names the recipe's own
output directory instead of results/; the Landlock/Seatbelt divergence
the narrowing exposed (`rm -rf $OUT` is EACCES on Linux, fine on macOS)
is recorded with its fix — delete the redundant prelude, the worker
resets the directory; the deposit-vs-consumed meaning of a File's sha256
is recorded where the old conflict rule used to be; and a comment that
referenced the spec stands on its own now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
Reuse: the annex pointer-shape test is assets.is_pointer now — one
spelling of git-annex's isPointerFile rule, used by require_fetched and
the crate's byte fallback alike, ending the cross-module reach into
private constants. dataset gains _ask, the one seam for git reads where
"cannot say" must be an answer (last_writer and annex_keys shared the
shape verbatim).

Altitude: the uv-scrub warning has one composer and only engine
surfaces — convergence's report, materialize's warnings, and the probe's
outcome notes — so the CLI never composes engine facts and lc run's
--json-less echo asymmetry is gone. The lc build echo is deleted
outright: the image's uv runs inside the container build, where the host
environment does not reach. The driver-resolved run facts (env_version,
HEAD, the versions memo, runtime, uv_version) are one frozen RunContext
handed to every task, so the next attestation field is one line instead
of an edit to five signatures.

Simplification: scrubbed_uv_vars folded into uv_scrub_warning (its only
caller); the test-only defaults on execute's uv_version and
LockScan.machine_config are gone — a real caller can no longer forget
either silently.

Efficiency: _dataset selects an output's files by bisecting one sorted
key list instead of re-sorting and rescanning the whole map per output,
and _integrity reads a file once.

Tests: one _git_calls helper in test_container, one _cluster helper for
the custom-scheduler tests in test_materialize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
Two host facts the runners exposed: CI pins its matrix interpreter
through an ambient UV_PYTHON, which the scrub correctly drops and
reports — so every converge grew the warning and warnings == []
depended on the host; an autouse fixture now strips scrubbable UV_*
suite-wide, derived from the scrub's own predicate. And the annex_keys
clone test ran `git annex init` in a clone with no git identity — a
clone inherits no local config, and init in a clone has remote
git-annex branch state to commit, where a fresh repo's init tolerates
the absence; the test sets identity the way the repo fixture does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
Widens #183's label gate: the eval runs on every non-draft PR, with
`ready_for_review` beside the default types so flipping a draft to
ready triggers the run the draft guard skips. The per-PR concurrency
group already cancels superseded runs, so a push train costs one eval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa
@github-actions

Copy link
Copy Markdown
Contributor

✅ Eval

Metric Value
Outputs check success
Agent run success
Turns 40
Tool calls 37
Cost $1.38
Agent wall time 5m38s
Model claude-sonnet-5
lc status
  mode:    direct
  sandbox: landlock (fs: declared, network: allowed)
  crate:   up to date with the outputs

  · current  baseline/best_fit        e3b93a3
  · current  baseline/hubble_diagram  e3b93a3
  · current  baseline/residuals       e3b93a3

3 current
Confusion & pain points (Claude analysis)

Confusion & pain points

  • No documented column schema for the input data file forced reverse-engineering. data/README.md only describes the declared-input mechanism, not what SCPUnion2.1_mu_vs_z.txt's five columns actually mean, so the agent had to head/wc/grep the raw file, run a web search, and fetch an external article that ultimately gave no useful answer — it ended up statistically inferring column semantics (checking col4/col5 ranges, uniqueness, and whether col5 > col4) rather than reading it from any spec or fixture. This is an eval-setup gap: a data file with undocumented columns should ship a data dictionary or the README should describe it.
  • Redshift-cut decision value didn't match the data. The agent tried redshift_cut: z_min_001 expecting it to filter some rows, then had to run a one-off numpy check (min z ... n<=0.01) to discover the cut removed zero rows, meaning the decision's threshold and the dataset's actual z-range were unverified assumptions baked into astra.yaml — a silent no-op that only manual probing caught.
  • Heavy local sanity-testing before ever invoking lc run/lc materialize. The agent ran the fit and plot scripts directly via .venv/bin/python against scratch /tmp output directories multiple times (different optimizers, error models, cuts) before trusting the pipeline enough to commit and materialize — reasonable diligence, but it suggests there's no lightweight lc-native way to dry-run a recipe against sample outputs without going through the full commit/materialize/annex cycle.
  • Otherwise a clean run. lc materialize, lc status, and astra validate all worked on the first real attempt once the recipes were wired up, and the RO-Crate/license flow (lc materialize warning about the missing .license, then succeeding after the edit) was self-explanatory from the tool's own output with no further digging needed.

Full trace: agent-trace artifact on this run.

@EiffL
EiffL merged commit 86e75f7 into clean_rebuild Aug 21, 2026
8 of 9 checks passed
@EiffL
EiffL deleted the hardening branch August 21, 2026 08:36
EiffL added a commit that referenced this pull request Aug 21, 2026
…ywhere

PR #184 dropped the OCI wrap's --network none so all three mechanisms
attest network: allowed symmetrically; the container internals page
still said recipes exec network-denied. Found by the drift check the
new check-docs prompt describes — a quoted flag the code no longer
delivers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx
EiffL added a commit that referenced this pull request Aug 21, 2026
Follow-up to the PR #190 eval trace, which surfaced three
harness-attributable frictions (the other findings were agent-side
noise).

## The `lc run` convention question, settled

Checked the ecosystem before touching anything: `uv run "python -V"`
fails with ``Failed to spawn: `python -V` `` (verified live), `docker
run img "echo hi"` fails with `exec: "echo hi": executable file not
found` — argv with no shell parsing is the convention across `docker
run`, `uv run`, `kubectl exec`, `pipx run`, `poetry run`, `srun`. **`lc
run` already matches it**, so the behavior stands unchanged and the
syntax gets one line in the two places it was missing:

- `evals/prompt.md`: "Argv style, like `docker run` or `uv run`: `lc run
python scripts/fit.py --output /tmp/x`, never a single quoted shell
string; for shell syntax use `lc run bash -c '...'`."
- `docs/cli/run.md`: same sentence, in the synopsis.

In the trace, the missing line cost one round trip through a genuinely
cryptic failure (`/usr/bin/env: '<whole string>': No such file or
directory` + the sandbox trailer insinuating a denial).

## Harness environment

- **`UV_PYTHON` noise**: `setup-uv`'s `python-version` input exports
`UV_PYTHON`, and the #184 install-settings scrub then (correctly) warned
`ignored ambient UV_PYTHON — …` on *every* `lc run`/`lc materialize` — a
dozen-plus unactionable lines the agent ended up `grep -v`'ing to read
its own output. The input is dropped; determinism is kept by pinning
`--python 3.12` on both tool installs. Same category as the
`VIRTUAL_ENV` cleanup earlier.
- **Truthful dev versions**: the shallow checkout had no tags, so
hatch-vcs stamped `0.1.dev1+g<sha>` into the installed engine — and into
every manifest's `lc_version`. `fetch-depth: 0` fixes it.

Validation: label this PR `run-eval` to see the updated prompt and clean
environment in action.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
EiffL added a commit that referenced this pull request Aug 21, 2026
…ibility (#190)

This merges the clean rebuild of lightcone-cli onto main — the full
re-architecture from the Snakemake shim to the model where **the locked
environment is the execution environment**. The pre-rebuild codebase was
deliberately stripped and functionality re-added one layer at a time,
each layer landing with its own tests, dependencies, and (since the
rewrite) docs. 21 PRs, all individually reviewed and merged into
`clean_rebuild`; this PR is the promotion.

## What lightcone-cli is now

A project is `pyproject.toml` + `uv.lock` + `astra.yaml` — uv is the
only environment substrate, and mode is derived, never configured:
direct by default (recipes in the project's `.venv` under
Landlock/Seatbelt), containerized when `[tool.lightcone.image]` is
declared (recipes in a content-addressed image archived in the
repository itself). Five verbs: `init`, `materialize`, `status`, `run`,
`build`.

- **Identity**: `definition_version` (what an output *is*) and
`env_version` (what it ran under) are separate on purpose — a spec
contradiction is `stale` and remade; an environment move is `behind`,
reported and left alone. `--refresh` widens by exactly that one state.
- **Storage**: the DataLad model — git carries history, git-annex
carries bytes, behind ordinary `git add`/`git commit`. Every output is
committed with a manifest and a run record `datalad rerun` can replay;
runs start and end on a clean tree; foreign writes are detected by
history and classify stale.
- **Execution**: one exec boundary (`Policy` → pure argv rewrite →
honest attestation) shared by recipes and the `lc run` probe; a recipe
writes its own output directory and nothing else; denials carry
copy-pasteable remedies. Network is uncontrolled on every mechanism,
symmetrically attested.
- **Venues**: detected, never configured — a SLURM allocation is spanned
with one worker per node; known centers get a login guard whose refusal
prints the center's own `salloc`/`sbatch` spellings; podman-hpc makes
multi-node containerized runs real.
- **Publication**: declaring `[project].license` turns on the RO-Crate
view — `ro-crate-metadata.json` converged by materialize, validated
against Provenance Run Crate 0.5 in CI. Deposit is `git archive`.
- **The engine is the host's uv tool, never a project dependency** — a
project's lock carries only what the analysis imports, and an engine
upgrade rebuilds nothing.

## The layers, as merged

| PR | Layer |
|---|---|
| #173 | Layer 5: the exec boundary and `lc run` (landed first — the
smallest honest consumer of the boundary) |
| #175 | Layers 2 + 4: identity, provenance, `lc materialize` |
| #178 | The engine is the host's uv tool (deletes layer 3 by decision)
|
| #180 | Layer 6: the container hatch |
| #181 | Layer 7: venues — SLURM in-allocation, podman-hpc |
| #182 | Layer 8: the publication view |
| #184 | Hardening: the recorded residues closed (UV_* scrub,
write-scope narrowing, symmetric network attestation, machine-level
uv.toml advisory) |
| #183 | The agentic eval reactivated: `uv tool install` of the branch
under test, empty-environment task, RO-Crate gate — three green runs |
| #185#189 | The documentation rewritten end to end: README, user
guide, CLI reference, developer corner, governance — every command block
executed before shipping, every refusal quoted from a real run |

## Verification

- The suite (tests, lint, mypy strict) is green on `clean_rebuild`,
including the three gated suites CI requires: sandbox enforcement
(Landlock on Linux, Seatbelt on macOS), container smoke (real build →
archive → bytes-free-clone `datalad rerun`), and crate smoke (the
official validator, REQUIRED clean).
- The agentic eval passes end-to-end on this branch: an agent builds a
supernova-cosmology analysis from the spec, manages the environment with
`uv add`, materializes through the real engine, and publishes a crate —
$0.70–$1.22 per run.
- `zensical build` renders the rewritten docs with zero issues; the site
deploys on the next release.

## Known deferred work (tracked, not blocking)

- The one-time **Perlmutter spike** (layer 7's pending items are listed
in CLAUDE.md) — HPC support ships honestly labeled "early days".
- **hub/GKE + Cloud Build** venue and **apptainer/singularity** runtimes
— design headroom recorded, deliberately not stubbed.
- Multi-arch image archives; the design-doc drop and CLAUDE.md slimming
when the rebuild formally closes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant